/*
 * Copyright 2007, MetaDimensional Technologies Inc.
 *
 *
 * This file is part of the RememberTheMilk Java API.
 *
 * The RememberTheMilk Java API is free software; you can redistribute it
 * and/or modify it under the terms of the GNU Lesser General Public License
 * as published by the Free Software Foundation; either version 3 of the
 * License, or (at your option) any later version.
 *
 * The RememberTheMilk Java API is distributed in the hope that it will be
 * useful, but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Lesser
 * General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */
package com.mdt.rtm;

import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;
import java.util.Collections;
import java.util.Date;
import java.util.List;

import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;

import org.apache.commons.codec.binary.Hex;
import org.apache.commons.httpclient.HttpClient;
import org.apache.commons.httpclient.HttpException;
import org.apache.commons.httpclient.HttpStatus;
import org.apache.commons.httpclient.NameValuePair;
import org.apache.commons.httpclient.URI;
import org.apache.commons.httpclient.URIException;
import org.apache.commons.httpclient.UsernamePasswordCredentials;
import org.apache.commons.httpclient.auth.AuthScope;
import org.apache.commons.httpclient.methods.GetMethod;
import org.apache.commons.httpclient.params.HttpMethodParams;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.xml.sax.SAXException;

/**
 * Handles the details of invoking a method on the RTM REST API.
 * 
 * @author Will Ross Jun 21, 2007
 */
public class Invoker
{

  private static final Log log = LogFactory.getLog("Invoker");

  private static final DocumentBuilder builder;
  static
  {
    DocumentBuilder b;
    try
    {
      DocumentBuilderFactory fact = DocumentBuilderFactory.newInstance();
      b = fact.newDocumentBuilder();
    }
    catch (Exception e)
    {
      log.error("Unable to construct document builder.", e);
      b = null;
    }
    builder = b;
  }

  private final String serviceBaseUrl;

  public static final String REST_SERVICE_URL_POSTFIX = "/services/rest/";

  public static final String ENC = "UTF-8";

  public static String API_SIG_PARAM = "api_sig";

  public static final long INVOCATION_INTERVAL = 2000;

  private long lastInvocation;

  private final ApplicationInfo applicationInfo;

  private final MessageDigest digest;

  private String proxyHostName;

  private int proxyPortNumber;

  private String proxyLogin;

  private String proxyPassword;

  public Invoker(String serviceBaseUrl, ApplicationInfo applicationInfo)
      throws ServiceInternalException
  {
    this.serviceBaseUrl = serviceBaseUrl;
    lastInvocation = System.currentTimeMillis();
    this.applicationInfo = applicationInfo;

    try
    {
      digest = MessageDigest.getInstance("md5");
    }
    catch (NoSuchAlgorithmException e)
    {
      throw new ServiceInternalException("Could not create properly the MD5 digest", e);
    }
  }

  public void setHttpProxySettings(String proxyHostName, int proxyPortNumber, String proxyLogin, String proxyPassword)
  {
    this.proxyHostName = proxyHostName;
    this.proxyPortNumber = proxyPortNumber;
    this.proxyLogin = proxyLogin;
    this.proxyPassword = proxyPassword;
  }

  public Element invoke(Param... params)
      throws ServiceException
  {
    Element result;

    long timeSinceLastInvocation = System.currentTimeMillis() - lastInvocation;
    if (timeSinceLastInvocation < INVOCATION_INTERVAL)
    {
      // In order not to invoke the RTM service too often
      try
      {
        Thread.sleep(INVOCATION_INTERVAL - timeSinceLastInvocation);
      }
      catch (InterruptedException e)
      {
        throw new ServiceInternalException("Unexpected interruption while attempting to pause for some time before invoking the RTM service back", e);
      }
    }

    log.debug("Invoker running at " + new Date());

    HttpClient client = new HttpClient();
    if (proxyHostName != null)
    {
      // Sets an HTTP proxy and the credentials for authentication
      client.getHostConfiguration().setProxy(proxyHostName, proxyPortNumber);
      if (proxyLogin != null)
      {
        client.getState().setProxyCredentials(AuthScope.ANY, new UsernamePasswordCredentials(proxyLogin, proxyPassword));
      }
    }
    GetMethod method = new GetMethod(serviceBaseUrl + REST_SERVICE_URL_POSTFIX);
    method.setRequestHeader(HttpMethodParams.HTTP_URI_CHARSET, "UTF-8");
    NameValuePair[] pairs = new NameValuePair[params.length + 1];
    int i = 0;
    for (Param param : params)
    {
      log.debug("  setting " + param.getName() + "=" + param.getValue());
      pairs[i++] = param.toNameValuePair();
    }
    pairs[i++] = new NameValuePair(API_SIG_PARAM, calcApiSig(params));
    method.setQueryString(pairs);

    try
    {
      URI methodUri;
      try
      {
        methodUri = method.getURI();
        log.info("Executing the method:" + methodUri);
      }
      catch (URIException exception)
      {
        String message = "Cannot determine the URI of the web method";
        log.error(message);
        throw new ServiceInternalException(message, exception);
      }
      int statusCode = client.executeMethod(method);

      if (statusCode != HttpStatus.SC_OK)
      {
        log.error("Method failed: " + method.getStatusLine());
        throw new ServiceInternalException("method failed: " + method.getStatusLine());
      }

      // THINK: this method is deprecated, but the only way to get the body as a string, without consuming
      // the body input stream: the HttpMethodBase issues a warning but does not let you call the "setResponseStream()" method!
      String responseBodyAsString = method.getResponseBodyAsString();
      log.info("  Invocation response:\r\n" + responseBodyAsString);
      Document responseDoc = builder.parse(method.getResponseBodyAsStream());
      Element wrapperElt = responseDoc.getDocumentElement();
      if (!wrapperElt.getNodeName().equals("rsp"))
      {
        throw new ServiceInternalException("unexpected response returned by RTM service: " + responseBodyAsString);
      }
      else
      {
        String stat = wrapperElt.getAttribute("stat");
        if (stat.equals("fail"))
        {
          Node errElt = wrapperElt.getFirstChild();
          while (errElt != null && (errElt.getNodeType() != Node.ELEMENT_NODE || !errElt.getNodeName().equals("err")))
          {
            errElt = errElt.getNextSibling();
          }
          if (errElt == null)
          {
            throw new ServiceInternalException("unexpected response returned by RTM service: " + responseBodyAsString);
          }
          else
          {
            throw new ServiceException(Integer.parseInt(((Element) errElt).getAttribute("code")), ((Element) errElt).getAttribute("msg"));
          }
        }
        else
        {
          Node dataElt = wrapperElt.getFirstChild();
          while (dataElt != null && (dataElt.getNodeType() != Node.ELEMENT_NODE || dataElt.getNodeName().equals("transaction") == true))
          {
            try
            {
              Node nextSibling = dataElt.getNextSibling();
              if (nextSibling == null)
              {
                break;
              }
              else
              {
                dataElt = nextSibling;
              }
            }
            catch (IndexOutOfBoundsException exception)
            {
              // Some implementation may throw this exception, instead of returning a null sibling
              break;
            }
          }
          if (dataElt == null)
          {
            throw new ServiceInternalException("unexpected response returned by RTM service: " + responseBodyAsString);
          }
          else
          {
            result = (Element) dataElt;
          }
        }
      }

    }
    catch (HttpException e)
    {
      throw new ServiceInternalException("", e);
    }
    catch (IOException e)
    {
      throw new ServiceInternalException("", e);
    }
    catch (SAXException e)
    {
      throw new ServiceInternalException("", e);
    }
    finally
    {
      // Release the connection.
      method.releaseConnection();
    }

    lastInvocation = System.currentTimeMillis();
    return result;
  }

  final String calcApiSig(Param... params)
      throws ServiceInternalException
  {
    try
    {
      digest.reset();
      digest.update(applicationInfo.getSharedSecret().getBytes(ENC));
      List<Param> sorted = Arrays.asList(params);
      Collections.sort(sorted);
      for (Param param : sorted)
      {
        digest.update(param.getName().getBytes(ENC));
        digest.update(param.getValue().getBytes(ENC));
      }
      return new String(Hex.encodeHex(digest.digest()));
      // return new String(digest.digest(), ENC);
    }
    catch (UnsupportedEncodingException e)
    {
      throw new ServiceInternalException("cannot hahdle properly the encoding", e);
    }
  }

}
